Add push-to-trunk python port.
authormachenbach@chromium.org <machenbach@chromium.org@ce2b1a6d-e550-0410-aec6-3dcde31c8c00>
Fri, 8 Nov 2013 14:27:16 +0000 (14:27 +0000)
committermachenbach@chromium.org <machenbach@chromium.org@ce2b1a6d-e550-0410-aec6-3dcde31c8c00>
Fri, 8 Nov 2013 14:27:16 +0000 (14:27 +0000)
Preliminary version.

TODO: Add wrapper script in tools dir.
TODO: Some refactoring.

R=jkummerow@chromium.org

Review URL: https://codereview.chromium.org/49653002

git-svn-id: http://v8.googlecode.com/svn/branches/bleeding_edge@17600 ce2b1a6d-e550-0410-aec6-3dcde31c8c00

tools/push-to-trunk/common_includes.py [new file with mode: 0644]
tools/push-to-trunk/push_to_trunk.py [new file with mode: 0755]
tools/push-to-trunk/test_scripts.py [new file with mode: 0644]

diff --git a/tools/push-to-trunk/common_includes.py b/tools/push-to-trunk/common_includes.py
new file mode 100644 (file)
index 0000000..3b2eab0
--- /dev/null
@@ -0,0 +1,289 @@
+#!/usr/bin/env python
+# Copyright 2013 the V8 project authors. All rights reserved.
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+#       copyright notice, this list of conditions and the following
+#       disclaimer in the documentation and/or other materials provided
+#       with the distribution.
+#     * Neither the name of Google Inc. nor the names of its
+#       contributors may be used to endorse or promote products derived
+#       from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import re
+import subprocess
+import sys
+
+PERSISTFILE_BASENAME = "PERSISTFILE_BASENAME"
+TEMP_BRANCH = "TEMP_BRANCH"
+BRANCHNAME = "BRANCHNAME"
+DOT_GIT_LOCATION = "DOT_GIT_LOCATION"
+VERSION_FILE = "VERSION_FILE"
+CHANGELOG_FILE = "CHANGELOG_FILE"
+CHANGELOG_ENTRY_FILE = "CHANGELOG_ENTRY_FILE"
+COMMITMSG_FILE = "COMMITMSG_FILE"
+PATCH_FILE = "PATCH_FILE"
+
+
+def TextToFile(text, file_name):
+  with open(file_name, "w") as f:
+    f.write(text)
+
+
+def AppendToFile(text, file_name):
+  with open(file_name, "a") as f:
+    f.write(text)
+
+
+def LinesInFile(file_name):
+  with open(file_name) as f:
+    for line in f:
+      yield line
+
+
+def FileToText(file_name):
+  with open(file_name) as f:
+    return f.read()
+
+
+def MSub(rexp, replacement, text):
+  return re.sub(rexp, replacement, text, flags=re.MULTILINE)
+
+
+# Some commands don't like the pipe, e.g. calling vi from within the script or
+# from subscripts like git cl upload.
+def Command(cmd, args="", prefix="", pipe=True):
+  cmd_line = "%s %s %s" % (prefix, cmd, args)
+  print "Command: %s" % cmd_line
+  try:
+    if pipe:
+      return subprocess.check_output(cmd_line, shell=True)
+    else:
+      return subprocess.check_call(cmd_line, shell=True)
+  except subprocess.CalledProcessError:
+    return None
+
+
+# Wrapper for side effects.
+class SideEffectHandler(object):
+  def Command(self, cmd, args="", prefix="", pipe=True):
+    return Command(cmd, args, prefix, pipe)
+
+  def ReadLine(self):
+    return sys.stdin.readline().strip()
+
+DEFAULT_SIDE_EFFECT_HANDLER = SideEffectHandler()
+
+
+class Step(object):
+  def __init__(self, text="", requires=None):
+    self._text = text
+    self._number = -1
+    self._requires = requires
+    self._side_effect_handler = DEFAULT_SIDE_EFFECT_HANDLER
+
+  def SetNumber(self, number):
+    self._number = number
+
+  def SetConfig(self, config):
+    self._config = config
+
+  def SetState(self, state):
+    self._state = state
+
+  def SetOptions(self, options):
+    self._options = options
+
+  def SetSideEffectHandler(self, handler):
+    self._side_effect_handler = handler
+
+  def Config(self, key):
+    return self._config[key]
+
+  def Run(self):
+    assert self._number >= 0
+    assert self._config is not None
+    assert self._state is not None
+    assert self._side_effect_handler is not None
+    if self._requires:
+      self.RestoreIfUnset(self._requires)
+      if not self._state[self._requires]:
+        return
+    print ">>> Step %d: %s" % (self._number, self._text)
+    self.RunStep()
+
+  def RunStep(self):
+    raise NotImplementedError
+
+  def ReadLine(self):
+    return self._side_effect_handler.ReadLine()
+
+  def Git(self, args="", prefix="", pipe=True):
+    return self._side_effect_handler.Command("git", args, prefix, pipe)
+
+  def Editor(self, args):
+    return self._side_effect_handler.Command(os.environ["EDITOR"], args,
+                                             pipe=False)
+
+  def Die(self, msg=""):
+    if msg != "":
+      print "Error: %s" % msg
+    print "Exiting"
+    raise Exception(msg)
+
+  def Confirm(self, msg):
+    print "%s [Y/n] " % msg,
+    answer = self.ReadLine()
+    return answer == "" or answer == "Y" or answer == "y"
+
+  def DeleteBranch(self, name):
+    git_result = self.Git("branch").strip()
+    for line in git_result.splitlines():
+      if re.match(r".*\s+%s$" % name, line):
+        msg = "Branch %s exists, do you want to delete it?" % name
+        if self.Confirm(msg):
+          if self.Git("branch -D %s" % name) is None:
+            self.Die("Deleting branch '%s' failed." % name)
+          print "Branch %s deleted." % name
+        else:
+          msg = "Can't continue. Please delete branch %s and try again." % name
+          self.Die(msg)
+
+  def Persist(self, var, value):
+    value = value or "__EMPTY__"
+    TextToFile(value, "%s-%s" % (self._config[PERSISTFILE_BASENAME], var))
+
+  def Restore(self, var):
+    value = FileToText("%s-%s" % (self._config[PERSISTFILE_BASENAME], var))
+    value = value or self.Die("Variable '%s' could not be restored." % var)
+    return "" if value == "__EMPTY__" else value
+
+  def RestoreIfUnset(self, var_name):
+    if self._state.get(var_name) is None:
+      self._state[var_name] = self.Restore(var_name)
+
+  def InitialEnvironmentChecks(self):
+    # Cancel if this is not a git checkout.
+    if not os.path.exists(self._config[DOT_GIT_LOCATION]):
+      self.Die("This is not a git checkout, this script won't work for you.")
+
+    # Cancel if EDITOR is unset or not executable.
+    if (not os.environ.get("EDITOR") or
+        Command("which", os.environ["EDITOR"]) is None):
+      self.Die("Please set your EDITOR environment variable, you'll need it.")
+
+  def CommonPrepare(self):
+    # Check for a clean workdir.
+    if self.Git("status -s -uno").strip() != "":
+      self.Die("Workspace is not clean. Please commit or undo your changes.")
+
+    # Persist current branch.
+    current_branch = ""
+    git_result = self.Git("status -s -b -uno").strip()
+    for line in git_result.splitlines():
+      match = re.match(r"^## (.+)", line)
+      if match:
+        current_branch = match.group(1)
+        break
+    self.Persist("current_branch", current_branch)
+
+    # Fetch unfetched revisions.
+    if self.Git("svn fetch") is None:
+      self.Die("'git svn fetch' failed.")
+
+    # Get ahold of a safe temporary branch and check it out.
+    if current_branch != self._config[TEMP_BRANCH]:
+      self.DeleteBranch(self._config[TEMP_BRANCH])
+      self.Git("checkout -b %s" % self._config[TEMP_BRANCH])
+
+    # Delete the branch that will be created later if it exists already.
+    self.DeleteBranch(self._config[BRANCHNAME])
+
+  def CommonCleanup(self):
+    self.RestoreIfUnset("current_branch")
+    self.Git("checkout -f %s" % self._state["current_branch"])
+    if self._config[TEMP_BRANCH] != self._state["current_branch"]:
+      self.Git("branch -D %s" % self._config[TEMP_BRANCH])
+    if self._config[BRANCHNAME] != self._state["current_branch"]:
+      self.Git("branch -D %s" % self._config[BRANCHNAME])
+
+    # Clean up all temporary files.
+    Command("rm", "-f %s*" % self._config[PERSISTFILE_BASENAME])
+
+  def ReadAndPersistVersion(self, prefix=""):
+    def ReadAndPersist(var_name, def_name):
+      match = re.match(r"^#define %s\s+(\d*)" % def_name, line)
+      if match:
+        value = match.group(1)
+        self.Persist("%s%s" % (prefix, var_name), value)
+        self._state["%s%s" % (prefix, var_name)] = value
+    for line in LinesInFile(self._config[VERSION_FILE]):
+      for (var_name, def_name) in [("major", "MAJOR_VERSION"),
+                                   ("minor", "MINOR_VERSION"),
+                                   ("build", "BUILD_NUMBER"),
+                                   ("patch", "PATCH_LEVEL")]:
+        ReadAndPersist(var_name, def_name)
+
+  def RestoreVersionIfUnset(self, prefix=""):
+    for v in ["major", "minor", "build", "patch"]:
+      self.RestoreIfUnset("%s%s" % (prefix, v))
+
+  def WaitForLGTM(self):
+    print ("Please wait for an LGTM, then type \"LGTM<Return>\" to commit "
+           "your change. (If you need to iterate on the patch or double check "
+           "that it's sane, do so in another shell, but remember to not "
+           "change the headline of the uploaded CL.")
+    answer = ""
+    while answer != "LGTM":
+      print "> ",
+      answer = self.ReadLine()
+      if answer != "LGTM":
+        print "That was not 'LGTM'."
+
+  def WaitForResolvingConflicts(self, patch_file):
+    print("Applying the patch \"%s\" failed. Either type \"ABORT<Return>\", "
+          "or resolve the conflicts, stage *all* touched files with "
+          "'git add', and type \"RESOLVED<Return>\"")
+    answer = ""
+    while answer != "RESOLVED":
+      if answer == "ABORT":
+        self.Die("Applying the patch failed.")
+      if answer != "":
+        print "That was not 'RESOLVED' or 'ABORT'."
+      print "> ",
+      answer = self.ReadLine()
+
+  # Takes a file containing the patch to apply as first argument.
+  def ApplyPatch(self, patch_file, reverse_patch=""):
+    args = "apply --index --reject %s \"%s\"" % (reverse_patch, patch_file)
+    if self.Git(args) is None:
+      self.WaitForResolvingConflicts(patch_file)
+
+
+class UploadStep(Step):
+  def __init__(self):
+    Step.__init__(self, "Upload for code review.")
+
+  def RunStep(self):
+    print "Please enter the email address of a V8 reviewer for your patch: ",
+    reviewer = self.ReadLine()
+    args = "cl upload -r \"%s\" --send-mail" % reviewer
+    if self.Git(args,pipe=False) is None:
+      self.Die("'git cl upload' failed, please try again.")
diff --git a/tools/push-to-trunk/push_to_trunk.py b/tools/push-to-trunk/push_to_trunk.py
new file mode 100755 (executable)
index 0000000..6ec03cb
--- /dev/null
@@ -0,0 +1,592 @@
+#!/usr/bin/env python
+# Copyright 2013 the V8 project authors. All rights reserved.
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+#       copyright notice, this list of conditions and the following
+#       disclaimer in the documentation and/or other materials provided
+#       with the distribution.
+#     * Neither the name of Google Inc. nor the names of its
+#       contributors may be used to endorse or promote products derived
+#       from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import datetime
+import optparse
+import sys
+import tempfile
+
+from common_includes import *
+
+TRUNKBRANCH = "TRUNKBRANCH"
+CHROMIUM = "CHROMIUM"
+DEPS_FILE = "DEPS_FILE"
+
+CONFIG = {
+  BRANCHNAME: "prepare-push",
+  TRUNKBRANCH: "trunk-push",
+  PERSISTFILE_BASENAME: "/tmp/v8-push-to-trunk-tempfile",
+  TEMP_BRANCH: "prepare-push-temporary-branch-created-by-script",
+  DOT_GIT_LOCATION: ".git",
+  VERSION_FILE: "src/version.cc",
+  CHANGELOG_FILE: "ChangeLog",
+  CHANGELOG_ENTRY_FILE: "/tmp/v8-push-to-trunk-tempfile-changelog-entry",
+  PATCH_FILE: "/tmp/v8-push-to-trunk-tempfile-patch-file",
+  COMMITMSG_FILE: "/tmp/v8-push-to-trunk-tempfile-commitmsg",
+  DEPS_FILE: "DEPS",
+}
+
+
+class Preparation(Step):
+  def __init__(self):
+    Step.__init__(self, "Preparation.")
+
+  def RunStep(self):
+    self.InitialEnvironmentChecks()
+    self.CommonPrepare()
+    self.DeleteBranch(self.Config(TRUNKBRANCH))
+
+
+class FreshBranch(Step):
+  def __init__(self):
+    Step.__init__(self, "Create a fresh branch.")
+
+  def RunStep(self):
+    args = "checkout -b %s svn/bleeding_edge" % self.Config(BRANCHNAME)
+    if self.Git(args) is None:
+      self.Die("Creating branch %s failed." % self.Config(BRANCHNAME))
+
+
+class DetectLastPush(Step):
+  def __init__(self):
+    Step.__init__(self, "Detect commit ID of last push to trunk.")
+
+  def RunStep(self):
+    last_push = (self._options.l or
+                 self.Git("log -1 --format=%H ChangeLog").strip())
+    while True:
+      # Print assumed commit, circumventing git's pager.
+      print self.Git("log -1 %s" % last_push)
+      if self.Confirm("Is the commit printed above the last push to trunk?"):
+        break
+      args = "log -1 --format=%H %s^ ChangeLog" % last_push
+      last_push = self.Git(args).strip()
+    self.Persist("last_push", last_push)
+    self._state["last_push"] = last_push
+
+
+class PrepareChangeLog(Step):
+  def __init__(self):
+    Step.__init__(self, "Prepare raw ChangeLog entry.")
+
+  def RunStep(self):
+    self.RestoreIfUnset("last_push")
+
+    # These version numbers are used again later for the trunk commit.
+    self.ReadAndPersistVersion()
+
+    date = datetime.date.today().strftime("%Y-%m-%d")
+    self.Persist("date", date)
+    output = "%s: Version %s.%s.%s\n\n" % (date,
+                                           self._state["major"],
+                                           self._state["minor"],
+                                           self._state["build"])
+    TextToFile(output, self.Config(CHANGELOG_ENTRY_FILE))
+
+    args = "log %s..HEAD --format=%%H" % self._state["last_push"]
+    commits = self.Git(args).strip()
+    for commit in commits.splitlines():
+      # Get the commit's title line.
+      args = "log -1 %s --format=\"%%w(80,8,8)%%s\"" % commit
+      title = "%s\n" % self.Git(args).rstrip()
+      AppendToFile(title, self.Config(CHANGELOG_ENTRY_FILE))
+
+      # Grep for "BUG=xxxx" lines in the commit message and convert them to
+      # "(issue xxxx)".
+      out = self.Git("log -1 %s --format=\"%%B\"" % commit).splitlines()
+      out = filter(lambda x: re.search(r"^BUG=", x), out)
+      out = filter(lambda x: not re.search(r"BUG=$", x), out)
+      out = filter(lambda x: not re.search(r"BUG=none$", x), out)
+
+      # TODO(machenbach): Handle multiple entries (e.g. BUG=123, 234).
+      def FormatIssue(text):
+        text = re.sub(r"BUG=v8:(.*)$", r"(issue \1)", text)
+        text = re.sub(r"BUG=chromium:(.*)$", r"(Chromium issue \1)", text)
+        text = re.sub(r"BUG=(.*)$", r"(Chromium issue \1)", text)
+        return "        %s\n" % text
+
+      for line in map(FormatIssue, out):
+        AppendToFile(line, self.Config(CHANGELOG_ENTRY_FILE))
+
+      # Append the commit's author for reference.
+      args = "log -1 %s --format=\"%%w(80,8,8)(%%an)\"" % commit
+      author = self.Git(args).rstrip()
+      AppendToFile("%s\n\n" % author, self.Config(CHANGELOG_ENTRY_FILE))
+
+    msg = "        Performance and stability improvements on all platforms.\n"
+    AppendToFile(msg, self.Config(CHANGELOG_ENTRY_FILE))
+
+class EditChangeLog(Step):
+  def __init__(self):
+    Step.__init__(self, "Edit ChangeLog entry.")
+
+  def RunStep(self):
+    print ("Please press <Return> to have your EDITOR open the ChangeLog "
+           "entry, then edit its contents to your liking. When you're done, "
+           "save the file and exit your EDITOR. ")
+    self.ReadLine()
+
+    self.Editor(self.Config(CHANGELOG_ENTRY_FILE))
+    handle, new_changelog = tempfile.mkstemp()
+    os.close(handle)
+
+    # (1) Eliminate tabs, (2) fix too little and (3) too much indentation, and
+    # (4) eliminate trailing whitespace.
+    changelog_entry = FileToText(self.Config(CHANGELOG_ENTRY_FILE)).rstrip()
+    changelog_entry = MSub(r"\t", r"        ", changelog_entry)
+    changelog_entry = MSub(r"^ {1,7}([^ ])", r"        \1", changelog_entry)
+    changelog_entry = MSub(r"^ {9,80}([^ ])", r"        \1", changelog_entry)
+    changelog_entry = MSub(r" +$", r"", changelog_entry)
+
+    if changelog_entry == "":
+      self.Die("Empty ChangeLog entry.")
+
+    with open(new_changelog, "w") as f:
+      f.write(changelog_entry)
+      f.write("\n\n\n")  # Explicitly insert two empty lines.
+
+    AppendToFile(FileToText(self.Config(CHANGELOG_FILE)), new_changelog)
+    TextToFile(FileToText(new_changelog), self.Config(CHANGELOG_FILE))
+    os.remove(new_changelog)
+
+
+class IncrementVersion(Step):
+  def __init__(self):
+    Step.__init__(self, "Increment version number.")
+
+  def RunStep(self):
+    self.RestoreIfUnset("build")
+    new_build = str(int(self._state["build"]) + 1)
+
+    if self.Confirm(("Automatically increment BUILD_NUMBER? (Saying 'n' will "
+                     "fire up your EDITOR on %s so you can make arbitrary "
+                     "changes. When you're done, save the file and exit your "
+                     "EDITOR.)" % self.Config(VERSION_FILE))):
+      text = FileToText(self.Config(VERSION_FILE))
+      text = MSub(r"(?<=#define BUILD_NUMBER)(?P<space>\s+)\d*$",
+                  r"\g<space>%s" % new_build,
+                  text)
+      TextToFile(text, self.Config(VERSION_FILE))
+    else:
+      self.Editor(self.Config(VERSION_FILE))
+
+    self.ReadAndPersistVersion("new_")
+
+
+class CommitLocal(Step):
+  def __init__(self):
+    Step.__init__(self, "Commit to local branch.")
+
+  def RunStep(self):
+    self.RestoreVersionIfUnset("new_")
+    prep_commit_msg = ("Prepare push to trunk.  "
+        "Now working on version %s.%s.%s." % (self._state["new_major"],
+                                              self._state["new_minor"],
+                                              self._state["new_build"]))
+    self.Persist("prep_commit_msg", prep_commit_msg)
+    if self.Git("commit -a -m \"%s\"" % prep_commit_msg) is None:
+      self.Die("'git commit -a' failed.")
+
+
+class CommitRepository(Step):
+  def __init__(self):
+    Step.__init__(self, "Commit to the repository.")
+
+  def RunStep(self):
+    self.WaitForLGTM()
+    # Re-read the ChangeLog entry (to pick up possible changes).
+    # FIXME(machenbach): This was hanging once with a broken pipe.
+    TextToFile(Command("cat %s | awk --posix '{\
+        if ($0 ~ /^[0-9]{4}-[0-9]{2}-[0-9]{2}:/) {\
+          if (in_firstblock == 1) {\
+            exit 0;\
+          } else {\
+            in_firstblock = 1;\
+          }\
+        };\
+        print $0;\
+      }'" % self.Config(CHANGELOG_FILE)), self.Config(CHANGELOG_ENTRY_FILE))
+
+    if self.Git("cl dcommit", "PRESUBMIT_TREE_CHECK=\"skip\"") is None:
+      self.Die("'git cl dcommit' failed, please try again.")
+
+
+class StragglerCommits(Step):
+  def __init__(self):
+    Step.__init__(self, "Fetch straggler commits that sneaked in since this "
+                        "script was started.")
+
+  def RunStep(self):
+    if self.Git("svn fetch") is None:
+      self.Die("'git svn fetch' failed.")
+    self.Git("checkout svn/bleeding_edge")
+    self.RestoreIfUnset("prep_commit_msg")
+    args = "log -1 --format=%%H --grep=\"%s\"" % self._state["prep_commit_msg"]
+    prepare_commit_hash = self.Git(args).strip()
+    self.Persist("prepare_commit_hash", prepare_commit_hash)
+
+
+class SquashCommits(Step):
+  def __init__(self):
+    Step.__init__(self, "Squash commits into one.")
+
+  def RunStep(self):
+    # Instead of relying on "git rebase -i", we'll just create a diff, because
+    # that's easier to automate.
+    self.RestoreIfUnset("prepare_commit_hash")
+    args = "diff svn/trunk %s" % self._state["prepare_commit_hash"]
+    TextToFile(self.Git(args), self.Config(PATCH_FILE))
+
+    # Convert the ChangeLog entry to commit message format:
+    # - remove date
+    # - remove indentation
+    # - merge paragraphs into single long lines, keeping empty lines between
+    #   them.
+    self.RestoreIfUnset("date")
+    changelog_entry = FileToText(self.Config(CHANGELOG_ENTRY_FILE))
+
+    # TODO(machenbach): This could create a problem if the changelog contained
+    # any quotation marks.
+    text = Command("echo \"%s\" \
+        | sed -e \"s/^%s: //\" \
+        | sed -e 's/^ *//' \
+        | awk '{ \
+            if (need_space == 1) {\
+              printf(\" \");\
+            };\
+            printf(\"%%s\", $0);\
+            if ($0 ~ /^$/) {\
+              printf(\"\\n\\n\");\
+              need_space = 0;\
+            } else {\
+              need_space = 1;\
+            }\
+          }'" % (changelog_entry, self._state["date"]))
+
+    if not text:
+      self.Die("Commit message editing failed.")
+    TextToFile(text, self.Config(COMMITMSG_FILE))
+    os.remove(self.Config(CHANGELOG_ENTRY_FILE))
+
+
+class NewBranch(Step):
+  def __init__(self):
+    Step.__init__(self, "Create a new branch from trunk.")
+
+  def RunStep(self):
+    if self.Git("checkout -b %s svn/trunk" % self.Config(TRUNKBRANCH)) is None:
+      self.Die("Checking out a new branch '%s' failed." %
+               self.Config(TRUNKBRANCH))
+
+
+class ApplyChanges(Step):
+  def __init__(self):
+    Step.__init__(self, "Apply squashed changes.")
+
+  def RunStep(self):
+    self.ApplyPatch(self.Config(PATCH_FILE))
+    Command("rm", "-f %s*" % self.Config(PATCH_FILE))
+
+
+class SetVersion(Step):
+  def __init__(self):
+    Step.__init__(self, "Set correct version for trunk.")
+
+  def RunStep(self):
+    self.RestoreVersionIfUnset()
+    output = ""
+    for line in FileToText(self.Config(VERSION_FILE)).splitlines():
+      if line.startswith("#define MAJOR_VERSION"):
+        line = re.sub("\d+$", self._state["major"], line)
+      elif line.startswith("#define MINOR_VERSION"):
+        line = re.sub("\d+$", self._state["minor"], line)
+      elif line.startswith("#define BUILD_NUMBER"):
+        line = re.sub("\d+$", self._state["build"], line)
+      elif line.startswith("#define PATCH_LEVEL"):
+        line = re.sub("\d+$", "0", line)
+      elif line.startswith("#define IS_CANDIDATE_VERSION"):
+        line = re.sub("\d+$", "0", line)
+      output += "%s\n" % line
+    TextToFile(output, self.Config(VERSION_FILE))
+
+
+class CommitTrunk(Step):
+  def __init__(self):
+    Step.__init__(self, "Commit to local trunk branch.")
+
+  def RunStep(self):
+    self.Git("add \"%s\"" % self.Config(VERSION_FILE))
+    if self.Git("commit -F \"%s\"" % self.Config(COMMITMSG_FILE)) is None:
+      self.Die("'git commit' failed.")
+    Command("rm", "-f %s*" % self.Config(COMMITMSG_FILE))
+
+
+class SanityCheck(Step):
+  def __init__(self):
+    Step.__init__(self, "Sanity check.")
+
+  def RunStep(self):
+    if not self.Confirm("Please check if your local checkout is sane: Inspect "
+        "%s, compile, run tests. Do you want to commit this new trunk "
+        "revision to the repository?" % self.Config(VERSION_FILE)):
+      self.Die("Execution canceled.")
+
+
+class CommitSVN(Step):
+  def __init__(self):
+    Step.__init__(self, "Commit to SVN.")
+
+  def RunStep(self):
+    result = self.Git("svn dcommit 2>&1")
+    if not result:
+      self.Die("'git svn dcommit' failed.")
+    result = filter(lambda x: re.search(r"^Committed r[0-9]+", x),
+                    result.splitlines())
+    if len(result) > 0:
+      trunk_revision = re.sub(r"^Committed r([0-9]+)", r"\1", result[0])
+
+    # Sometimes grepping for the revision fails. No idea why. If you figure
+    # out why it is flaky, please do fix it properly.
+    if not trunk_revision:
+      print("Sorry, grepping for the SVN revision failed. Please look for it "
+            "in the last command's output above and provide it manually (just "
+            "the number, without the leading \"r\").")
+      while not trunk_revision:
+        print "> ",
+        trunk_revision = self.ReadLine()
+    self.Persist("trunk_revision", trunk_revision)
+
+
+class TagRevision(Step):
+  def __init__(self):
+    Step.__init__(self, "Tag the new revision.")
+
+  def RunStep(self):
+    self.RestoreVersionIfUnset()
+    ver = "%s.%s.%s" % (self._state["major"],
+                        self._state["minor"],
+                        self._state["build"])
+    if self.Git("svn tag %s -m \"Tagging version %s\"" % (ver, ver)) is None:
+      self.Die("'git svn tag' failed.")
+
+
+class CheckChromium(Step):
+  def __init__(self):
+    Step.__init__(self, "Ask for chromium checkout.")
+
+  def Run(self):
+    chrome_path = self._options.c
+    if not chrome_path:
+      print ("Do you have a \"NewGit\" Chromium checkout and want "
+          "this script to automate creation of the roll CL? If yes, enter the "
+          "path to (and including) the \"src\" directory here, otherwise just "
+          "press <Return>: "),
+      chrome_path = self.ReadLine()
+    self.Persist("chrome_path", chrome_path)
+
+
+class SwitchChromium(Step):
+  def __init__(self):
+    Step.__init__(self, "Switch to Chromium checkout.", requires="chrome_path")
+
+  def RunStep(self):
+    v8_path = os.getcwd()
+    self.Persist("v8_path", v8_path)
+    os.chdir(self._state["chrome_path"])
+    self.InitialEnvironmentChecks()
+    # Check for a clean workdir.
+    if self.Git("status -s -uno").strip() != "":
+      self.Die("Workspace is not clean. Please commit or undo your changes.")
+    # Assert that the DEPS file is there.
+    if not os.path.exists(self.Config(DEPS_FILE)):
+      self.Die("DEPS file not present.")
+
+
+class UpdateChromiumCheckout(Step):
+  def __init__(self):
+    Step.__init__(self, "Update the checkout and create a new branch.",
+                  requires="chrome_path")
+
+  def RunStep(self):
+    os.chdir(self._state["chrome_path"])
+    if self.Git("checkout master") is None:
+      self.Die("'git checkout master' failed.")
+    if self.Git("pull") is None:
+      self.Die("'git pull' failed, please try again.")
+
+    self.RestoreIfUnset("trunk_revision")
+    args = "checkout -b v8-roll-%s" % self._state["trunk_revision"]
+    if self.Git(args) is None:
+      self.Die("Failed to checkout a new branch.")
+
+
+class UploadCL(Step):
+  def __init__(self):
+    Step.__init__(self, "Create and upload CL.", requires="chrome_path")
+
+  def RunStep(self):
+    os.chdir(self._state["chrome_path"])
+
+    # Patch DEPS file.
+    self.RestoreIfUnset("trunk_revision")
+    deps = FileToText(self.Config(DEPS_FILE))
+    deps = re.sub("(?<=\"v8_revision\": \")([0-9]+)(?=\")",
+                  self._state["trunk_revision"],
+                  deps)
+    TextToFile(deps, self.Config(DEPS_FILE))
+
+    self.RestoreVersionIfUnset()
+    ver = "%s.%s.%s" % (self._state["major"],
+                        self._state["minor"],
+                        self._state["build"])
+    print "Please enter the email address of a reviewer for the roll CL: ",
+    rev = self.ReadLine()
+    args = "commit -am \"Update V8 to version %s.\n\nTBR=%s\"" % (ver, rev)
+    if self.Git(args) is None:
+      self.Die("'git commit' failed.")
+    if self.Git("cl upload --send-mail", pipe=False) is None:
+      self.Die("'git cl upload' failed, please try again.")
+    print "CL uploaded."
+
+
+class SwitchV8(Step):
+  def __init__(self):
+    Step.__init__(self, "Returning to V8 checkout.", requires="chrome_path")
+
+  def RunStep(self):
+    self.RestoreIfUnset("v8_path")
+    os.chdir(self._state["v8_path"])
+
+
+class CleanUp(Step):
+  def __init__(self):
+    Step.__init__(self, "Done!")
+
+  def RunStep(self):
+    self.RestoreVersionIfUnset()
+    ver = "%s.%s.%s" % (self._state["major"],
+                        self._state["minor"],
+                        self._state["build"])
+    self.RestoreIfUnset("trunk_revision")
+    self.RestoreIfUnset("chrome_path")
+
+    if self._state["chrome_path"]:
+      print("Congratulations, you have successfully created the trunk "
+            "revision %s and rolled it into Chromium. Please don't forget to "
+            "update the v8rel spreadsheet:" % ver)
+    else:
+      print("Congratulations, you have successfully created the trunk "
+            "revision %s. Please don't forget to roll this new version into "
+            "Chromium, and to update the v8rel spreadsheet:" % ver)
+    print "%s\ttrunk\t%s" % (ver, self._state["trunk_revision"])
+
+    self.CommonCleanup()
+    if self.Config(TRUNKBRANCH) != self._state["current_branch"]:
+      self.Git("branch -D %s" % self.Config(TRUNKBRANCH))
+
+
+def RunScript(config,
+              options,
+              side_effect_handler=DEFAULT_SIDE_EFFECT_HANDLER):
+  step_classes = [
+    Preparation,
+    FreshBranch,
+    DetectLastPush,
+    PrepareChangeLog,
+    EditChangeLog,
+    IncrementVersion,
+    CommitLocal,
+    UploadStep,
+    CommitRepository,
+    StragglerCommits,
+    SquashCommits,
+    NewBranch,
+    ApplyChanges,
+    SetVersion,
+    CommitTrunk,
+    SanityCheck,
+    CommitSVN,
+    TagRevision,
+    CheckChromium,
+    SwitchChromium,
+    UpdateChromiumCheckout,
+    UploadCL,
+    SwitchV8,
+    CleanUp,
+  ]
+
+  state = {}
+  steps = []
+  number = 0
+
+  for step_class in step_classes:
+    # TODO(machenbach): Factory methods.
+    step = step_class()
+    step.SetNumber(number)
+    step.SetConfig(config)
+    step.SetOptions(options)
+    step.SetState(state)
+    step.SetSideEffectHandler(side_effect_handler)
+    steps.append(step)
+    number += 1
+
+  for step in steps[options.s:]:
+    step.Run()
+
+
+def BuildOptions():
+  result = optparse.OptionParser()
+  result.add_option("-s", "--step", dest="s",
+                    help="Specify the step where to start work. Default: 0.",
+                    default=0, type="int")
+  result.add_option("-l", "--last-push", dest="l",
+                    help=("Manually specify the git commit ID "
+                          "of the last push to trunk."))
+  result.add_option("-c", "--chromium", dest="c",
+                    help=("Specify the path to your Chromium src/ "
+                          "directory to automate the V8 roll."))
+  return result
+
+
+def ProcessOptions(options):
+  if options.s < 0:
+    print "Bad step number %d" % options.s
+    return False
+  return True
+
+
+def Main():
+  parser = BuildOptions()
+  (options, args) = parser.parse_args()
+  if not ProcessOptions(options):
+    parser.print_help()
+    return 1
+  RunScript(CONFIG, options)
+
+if __name__ == "__main__":
+  sys.exit(Main())
diff --git a/tools/push-to-trunk/test_scripts.py b/tools/push-to-trunk/test_scripts.py
new file mode 100644 (file)
index 0000000..9865ada
--- /dev/null
@@ -0,0 +1,428 @@
+#!/usr/bin/env python
+# Copyright 2013 the V8 project authors. All rights reserved.
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+#     * Redistributions of source code must retain the above copyright
+#       notice, this list of conditions and the following disclaimer.
+#     * Redistributions in binary form must reproduce the above
+#       copyright notice, this list of conditions and the following
+#       disclaimer in the documentation and/or other materials provided
+#       with the distribution.
+#     * Neither the name of Google Inc. nor the names of its
+#       contributors may be used to endorse or promote products derived
+#       from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+# A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+# OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+# LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+# DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+import os
+import tempfile
+import unittest
+
+import common_includes
+from common_includes import *
+import push_to_trunk
+from push_to_trunk import *
+
+
+TEST_CONFIG = {
+  BRANCHNAME: "test-prepare-push",
+  TRUNKBRANCH: "test-trunk-push",
+  PERSISTFILE_BASENAME: "/tmp/test-v8-push-to-trunk-tempfile",
+  TEMP_BRANCH: "test-prepare-push-temporary-branch-created-by-script",
+  DOT_GIT_LOCATION: None,
+  VERSION_FILE: None,
+  CHANGELOG_FILE: None,
+  CHANGELOG_ENTRY_FILE: "/tmp/test-v8-push-to-trunk-tempfile-changelog-entry",
+  PATCH_FILE: "/tmp/test-v8-push-to-trunk-tempfile-patch",
+  COMMITMSG_FILE: "/tmp/test-v8-push-to-trunk-tempfile-commitmsg",
+  CHROMIUM: "/tmp/test-v8-push-to-trunk-tempfile-chromium",
+  DEPS_FILE: "/tmp/test-v8-push-to-trunk-tempfile-chromium/DEPS",
+}
+
+
+class ScriptTest(unittest.TestCase):
+  def MakeEmptyTempFile(self):
+    handle, name = tempfile.mkstemp()
+    os.close(handle)
+    self._tmp_files.append(name)
+    return name
+
+  def MakeTempVersionFile(self):
+    name = self.MakeEmptyTempFile()
+    with open(name, "w") as f:
+      f.write("  // Some line...\n")
+      f.write("\n")
+      f.write("#define MAJOR_VERSION    3\n")
+      f.write("#define MINOR_VERSION    22\n")
+      f.write("#define BUILD_NUMBER     5\n")
+      f.write("#define PATCH_LEVEL      0\n")
+      f.write("  // Some line...\n")
+      f.write("#define IS_CANDIDATE_VERSION 0\n")
+    return name
+
+  def MakeStep(self, step_class=Step, state=None):
+    state = state or {}
+    step = step_class()
+    step.SetConfig(TEST_CONFIG)
+    step.SetState(state)
+    step.SetNumber(0)
+    step.SetSideEffectHandler(self)
+    return step
+
+  def GitMock(self, cmd, args="", pipe=True):
+    self._git_index += 1
+    try:
+      git_invocation = self._git_recipe[self._git_index]
+    except IndexError:
+      raise Exception("Calling git %s" % args)
+    if git_invocation[0] != args:
+      raise Exception("Expected: %s - Actual: %s" % (git_invocation[0], args))
+    if len(git_invocation) == 3:
+      # Run optional function checking the context during this git command.
+      git_invocation[2]()
+    return git_invocation[1]
+
+  def LogMock(self, cmd, args=""):
+    print "Log: %s %s" % (cmd, args)
+
+  MOCKS = {
+    "git": GitMock,
+    "vi": LogMock,
+  }
+
+  def Command(self, cmd, args="", prefix="", pipe=True):
+    return ScriptTest.MOCKS[cmd](self, cmd, args)
+
+  def ReadLine(self):
+    self._rl_index += 1
+    try:
+      return self._rl_recipe[self._rl_index]
+    except IndexError:
+      raise Exception("Calling readline too often")
+
+  def setUp(self):
+    self._git_recipe = []
+    self._git_index = -1
+    self._rl_recipe = []
+    self._rl_index = -1
+    self._tmp_files = []
+
+  def tearDown(self):
+    Command("rm", "-rf %s*" % TEST_CONFIG[PERSISTFILE_BASENAME])
+
+    # Clean up temps. Doesn't work automatically.
+    for name in self._tmp_files:
+      if os.path.exists(name):
+        os.remove(name)
+
+    if self._git_index < len(self._git_recipe) -1:
+      raise Exception("Called git too seldom: %d vs. %d" %
+                      (self._git_index, len(self._git_recipe)))
+    if self._rl_index < len(self._rl_recipe) -1:
+      raise Exception("Too little input: %d vs. %d" %
+                      (self._rl_index, len(self._rl_recipe)))
+
+  def testPersistRestore(self):
+    self.MakeStep().Persist("test1", "")
+    self.assertEquals("", self.MakeStep().Restore("test1"))
+    self.MakeStep().Persist("test2", "AB123")
+    self.assertEquals("AB123", self.MakeStep().Restore("test2"))
+
+  def testGitOrig(self):
+    self.assertTrue(Command("git", "--version").startswith("git version"))
+
+  def testGitMock(self):
+    self._git_recipe = [["--version", "git version 1.2.3"], ["dummy", ""]]
+    self.assertEquals("git version 1.2.3", self.MakeStep().Git("--version"))
+    self.assertEquals("", self.MakeStep().Git("dummy"))
+
+  def testCommonPrepareDefault(self):
+    self._git_recipe = [
+      ["status -s -uno", ""],
+      ["status -s -b -uno", "## some_branch"],
+      ["svn fetch", ""],
+      ["branch", "  branch1\n* %s" % TEST_CONFIG[TEMP_BRANCH]],
+      ["branch -D %s" % TEST_CONFIG[TEMP_BRANCH], ""],
+      ["checkout -b %s" % TEST_CONFIG[TEMP_BRANCH], ""],
+      ["branch", ""],
+    ]
+    self._rl_recipe = ["Y"]
+    self.MakeStep().CommonPrepare()
+    self.assertEquals("some_branch", self.MakeStep().Restore("current_branch"))
+
+  def testCommonPrepareNoConfirm(self):
+    self._git_recipe = [
+      ["status -s -uno", ""],
+      ["status -s -b -uno", "## some_branch"],
+      ["svn fetch", ""],
+      ["branch", "  branch1\n* %s" % TEST_CONFIG[TEMP_BRANCH]],
+    ]
+    self._rl_recipe = ["n"]
+    self.assertRaises(Exception, self.MakeStep().CommonPrepare)
+    self.assertEquals("some_branch", self.MakeStep().Restore("current_branch"))
+
+  def testCommonPrepareDeleteBranchFailure(self):
+    self._git_recipe = [
+      ["status -s -uno", ""],
+      ["status -s -b -uno", "## some_branch"],
+      ["svn fetch", ""],
+      ["branch", "  branch1\n* %s" % TEST_CONFIG[TEMP_BRANCH]],
+      ["branch -D %s" % TEST_CONFIG[TEMP_BRANCH], None],
+    ]
+    self._rl_recipe = ["Y"]
+    self.assertRaises(Exception, self.MakeStep().CommonPrepare)
+    self.assertEquals("some_branch", self.MakeStep().Restore("current_branch"))
+
+  def testInitialEnvironmentChecks(self):
+    TEST_CONFIG[DOT_GIT_LOCATION] = self.MakeEmptyTempFile()
+    os.environ["EDITOR"] = "vi"
+    self.MakeStep().InitialEnvironmentChecks()
+
+  def testReadAndPersistVersion(self):
+    TEST_CONFIG[VERSION_FILE] = self.MakeTempVersionFile()
+    step = self.MakeStep()
+    step.ReadAndPersistVersion()
+    self.assertEquals("3", self.MakeStep().Restore("major"))
+    self.assertEquals("22", self.MakeStep().Restore("minor"))
+    self.assertEquals("5", self.MakeStep().Restore("build"))
+    self.assertEquals("0", self.MakeStep().Restore("patch"))
+    self.assertEquals("3", step._state["major"])
+    self.assertEquals("22", step._state["minor"])
+    self.assertEquals("5", step._state["build"])
+    self.assertEquals("0", step._state["patch"])
+
+  def testRegex(self):
+    self.assertEqual("(issue 321)",
+                     re.sub(r"BUG=v8:(.*)$", r"(issue \1)", "BUG=v8:321"))
+    self.assertEqual("(Chromium issue 321)",
+                     re.sub(r"BUG=(.*)$", r"(Chromium issue \1)", "BUG=321"))
+
+    cl = "  too little\n\ttab\ttab\n         too much\n        trailing  "
+    cl = MSub(r"\t", r"        ", cl)
+    cl = MSub(r"^ {1,7}([^ ])", r"        \1", cl)
+    cl = MSub(r"^ {9,80}([^ ])", r"        \1", cl)
+    cl = MSub(r" +$", r"", cl)
+    self.assertEqual("        too little\n"
+                     "        tab        tab\n"
+                     "        too much\n"
+                     "        trailing", cl)
+
+    self.assertEqual("//\n#define BUILD_NUMBER  3\n",
+                     MSub(r"(?<=#define BUILD_NUMBER)(?P<space>\s+)\d*$",
+                          r"\g<space>3",
+                          "//\n#define BUILD_NUMBER  321\n"))
+
+  def testPrepareChangeLog(self):
+    TEST_CONFIG[VERSION_FILE] = self.MakeTempVersionFile()
+    TEST_CONFIG[CHANGELOG_ENTRY_FILE] = self.MakeEmptyTempFile()
+
+    self._git_recipe = [
+      ["log 1234..HEAD --format=%H", "rev1\nrev2"],
+      ["log -1 rev1 --format=\"%w(80,8,8)%s\"", "        Title text 1"],
+      ["log -1 rev1 --format=\"%B\"", "Title\n\nBUG=\n"],
+      ["log -1 rev1 --format=\"%w(80,8,8)(%an)\"",
+       "        author1@chromium.org"],
+      ["log -1 rev2 --format=\"%w(80,8,8)%s\"", "        Title text 2"],
+      ["log -1 rev2 --format=\"%B\"", "Title\n\nBUG=321\n"],
+      ["log -1 rev2 --format=\"%w(80,8,8)(%an)\"",
+       "        author2@chromium.org"],
+    ]
+
+    self.MakeStep().Persist("last_push", "1234")
+    self.MakeStep(PrepareChangeLog).Run()
+
+    cl = FileToText(TEST_CONFIG[CHANGELOG_ENTRY_FILE])
+    self.assertTrue(re.search(r"\d+\-\d+\-\d+: Version 3\.22\.5", cl))
+    self.assertTrue(re.search(r"        Title text 1", cl))
+    self.assertTrue(re.search(r"        Title text 2", cl))
+    self.assertTrue(re.search(r"        author1@chromium.org", cl))
+    self.assertTrue(re.search(r"        author2@chromium.org", cl))
+    self.assertTrue(re.search(r"        \(Chromium issue 321\)", cl))
+    self.assertFalse(re.search(r"BUG=", cl))
+    self.assertEquals("3", self.MakeStep().Restore("major"))
+    self.assertEquals("22", self.MakeStep().Restore("minor"))
+    self.assertEquals("5", self.MakeStep().Restore("build"))
+    self.assertEquals("0", self.MakeStep().Restore("patch"))
+
+  def testEditChangeLog(self):
+    TEST_CONFIG[CHANGELOG_ENTRY_FILE] = self.MakeEmptyTempFile()
+    TEST_CONFIG[CHANGELOG_FILE] = self.MakeEmptyTempFile()
+    TextToFile("        Original CL", TEST_CONFIG[CHANGELOG_FILE])
+    TextToFile("  New  \n\tLines  \n", TEST_CONFIG[CHANGELOG_ENTRY_FILE])
+    os.environ["EDITOR"] = "vi"
+
+    self._rl_recipe = [
+      "",  # Open editor.
+    ]
+
+    self.MakeStep(EditChangeLog).Run()
+
+    self.assertEquals("        New\n        Lines\n\n\n        Original CL",
+                      FileToText(TEST_CONFIG[CHANGELOG_FILE]))
+
+  def testIncrementVersion(self):
+    TEST_CONFIG[VERSION_FILE] = self.MakeTempVersionFile()
+    self.MakeStep().Persist("build", "5")
+
+    self._rl_recipe = [
+      "Y",  # Increment build number.
+    ]
+
+    self.MakeStep(IncrementVersion).Run()
+
+    self.assertEquals("3", self.MakeStep().Restore("new_major"))
+    self.assertEquals("22", self.MakeStep().Restore("new_minor"))
+    self.assertEquals("6", self.MakeStep().Restore("new_build"))
+    self.assertEquals("0", self.MakeStep().Restore("new_patch"))
+
+  def testSquashCommits(self):
+    TEST_CONFIG[CHANGELOG_ENTRY_FILE] = self.MakeEmptyTempFile()
+    with open(TEST_CONFIG[CHANGELOG_ENTRY_FILE], "w") as f:
+      f.write("1999-11-11: Version 3.22.5\n")
+      f.write("\n")
+      f.write("        Log text 1.\n")
+      f.write("        Chromium issue 12345\n")
+      f.write("\n")
+      f.write("        Performance and stability improvements on all "
+              "platforms.\n")
+
+    self._git_recipe = [
+      ["diff svn/trunk hash1", "patch content"],
+    ]
+
+    self.MakeStep().Persist("prepare_commit_hash", "hash1")
+    self.MakeStep().Persist("date", "1999-11-11")
+
+    self.MakeStep(SquashCommits).Run()
+
+    msg = FileToText(TEST_CONFIG[COMMITMSG_FILE])
+    self.assertTrue(re.search(r"Version 3\.22\.5", msg))
+    self.assertTrue(re.search(r"Performance and stability", msg))
+    self.assertTrue(re.search(r"Log text 1\. Chromium issue 12345", msg))
+    self.assertFalse(re.search(r"\d+\-\d+\-\d+", msg))
+
+    patch = FileToText(TEST_CONFIG[ PATCH_FILE])
+    self.assertTrue(re.search(r"patch content", patch))
+
+  def testPushToTrunk(self):
+    TEST_CONFIG[DOT_GIT_LOCATION] = self.MakeEmptyTempFile()
+    TEST_CONFIG[VERSION_FILE] = self.MakeTempVersionFile()
+    TEST_CONFIG[CHANGELOG_ENTRY_FILE] = self.MakeEmptyTempFile()
+    TEST_CONFIG[CHANGELOG_FILE] = self.MakeEmptyTempFile()
+    if not os.path.exists(TEST_CONFIG[CHROMIUM]):
+      os.makedirs(TEST_CONFIG[CHROMIUM])
+    TextToFile("1999-04-05: Version 3.22.4", TEST_CONFIG[CHANGELOG_FILE])
+    TextToFile("Some line\n   \"v8_revision\": \"123444\",\n  some line",
+                 TEST_CONFIG[DEPS_FILE])
+    os.environ["EDITOR"] = "vi"
+
+    def CheckPreparePush():
+      cl = FileToText(TEST_CONFIG[CHANGELOG_FILE])
+      self.assertTrue(re.search(r"Version 3.22.5", cl))
+      self.assertTrue(re.search(r"        Log text 1", cl))
+      self.assertTrue(re.search(r"        \(issue 321\)", cl))
+      version = FileToText(TEST_CONFIG[VERSION_FILE])
+      self.assertTrue(re.search(r"#define BUILD_NUMBER\s+6", version))
+
+    def CheckSVNCommit():
+      commit = FileToText(TEST_CONFIG[COMMITMSG_FILE])
+      self.assertTrue(re.search(r"Version 3.22.5", commit))
+      self.assertTrue(re.search(r"Log text 1. \(issue 321\)", commit))
+      version = FileToText(TEST_CONFIG[VERSION_FILE])
+      self.assertTrue(re.search(r"#define MINOR_VERSION\s+22", version))
+      self.assertTrue(re.search(r"#define BUILD_NUMBER\s+5", version))
+      self.assertFalse(re.search(r"#define BUILD_NUMBER\s+6", version))
+      self.assertTrue(re.search(r"#define PATCH_LEVEL\s+0", version))
+      self.assertTrue(re.search(r"#define IS_CANDIDATE_VERSION\s+0", version))
+
+    self._git_recipe = [
+      ["status -s -uno", ""],
+      ["status -s -b -uno", "## some_branch\n"],
+      ["svn fetch", ""],
+      ["branch", "  branch1\n* branch2\n"],
+      ["checkout -b %s" % TEST_CONFIG[TEMP_BRANCH], ""],
+      ["branch", "  branch1\n* branch2\n"],
+      ["branch", "  branch1\n* branch2\n"],
+      ["checkout -b %s svn/bleeding_edge" % TEST_CONFIG[BRANCHNAME], ""],
+      ["log -1 --format=%H ChangeLog", "1234\n"],
+      ["log -1 1234", "Last push ouput\n"],
+      ["log 1234..HEAD --format=%H", "rev1\n"],
+      ["log -1 rev1 --format=\"%w(80,8,8)%s\"", "        Log text 1.\n"],
+      ["log -1 rev1 --format=\"%B\"", "Text\nBUG=v8:321\nText\n"],
+      ["log -1 rev1 --format=\"%w(80,8,8)(%an)\"",
+       "        author1@chromium.org\n"],
+      [("commit -a -m \"Prepare push to trunk.  "
+        "Now working on version 3.22.6.\""),
+       " 2 files changed\n",
+        CheckPreparePush],
+      ["cl upload -r \"reviewer@chromium.org\" --send-mail", "done\n"],
+      ["cl dcommit", "Closing issue\n"],
+      ["svn fetch", "fetch result\n"],
+      ["checkout svn/bleeding_edge", ""],
+      [("log -1 --format=%H --grep=\"Prepare push to trunk.  "
+        "Now working on version 3.22.6.\""),
+       "hash1\n"],
+      ["diff svn/trunk hash1", "patch content\n"],
+      ["checkout -b %s svn/trunk" % TEST_CONFIG[TRUNKBRANCH], ""],
+      ["apply --index --reject  \"%s\"" % TEST_CONFIG[PATCH_FILE], ""],
+      ["add \"%s\"" % TEST_CONFIG[VERSION_FILE], ""],
+      ["commit -F \"%s\"" % TEST_CONFIG[COMMITMSG_FILE], "", CheckSVNCommit],
+      ["svn dcommit 2>&1", "Some output\nCommitted r123456\nSome output\n"],
+      ["svn tag 3.22.5 -m \"Tagging version 3.22.5\"", ""],
+      ["status -s -uno", ""],
+      ["checkout master", ""],
+      ["pull", ""],
+      ["checkout -b v8-roll-123456", ""],
+      [("commit -am \"Update V8 to version 3.22.5.\n\n"
+        "TBR=reviewer@chromium.org\""),
+       ""],
+      ["cl upload --send-mail", ""],
+      ["checkout -f some_branch", ""],
+      ["branch -D %s" % TEST_CONFIG[TEMP_BRANCH], ""],
+      ["branch -D %s" % TEST_CONFIG[BRANCHNAME], ""],
+      ["branch -D %s" % TEST_CONFIG[TRUNKBRANCH], ""],
+    ]
+    self._rl_recipe = [
+      "Y",  # Confirm last push.
+      "",  # Open editor.
+      "Y",  # Increment build number.
+      "reviewer@chromium.org",  # V8 reviewer.
+      "LGTX",  # Enter LGTM for V8 CL (wrong).
+      "LGTM",  # Enter LGTM for V8 CL.
+      "Y",  # Sanity check.
+      "reviewer@chromium.org",  # Chromium reviewer.
+    ]
+
+    class Options( object ):
+      pass
+
+    options = Options()
+    options.s = 0
+    options.l = None
+    options.c = TEST_CONFIG[CHROMIUM]
+    RunScript(TEST_CONFIG, options, self)
+
+    deps = FileToText(TEST_CONFIG[DEPS_FILE])
+    self.assertTrue(re.search("\"v8_revision\": \"123456\"", deps))
+
+    cl = FileToText(TEST_CONFIG[CHANGELOG_FILE])
+    self.assertTrue(re.search(r"\d\d\d\d\-\d+\-\d+: Version 3\.22\.5", cl))
+    self.assertTrue(re.search(r"        Log text 1", cl))
+    self.assertTrue(re.search(r"        \(issue 321\)", cl))
+    self.assertTrue(re.search(r"1999\-04\-05: Version 3\.22\.4", cl))
+
+    # Note: The version file is on build number 5 again in the end of this test
+    # since the git command that merges to the bleeding edge branch is mocked
+    # out.